iT邦幫忙

2024 iThome 鐵人賽

DAY 22
0
Mobile Development

從零開始以Flutter打造跨平台聊天APP系列 第 22

Day-22 實作(3) Flutter 如何使用 CustomScrollView

  • 分享至 

  • xImage
  •  

Generated from Stable Diffusion 3 Medium

接下來的幾天我們會運用我們所學,繼續完成我的聊天 APP - Whisper

這是今天的參考程式碼:https://github.com/ksw2000/ironman-2024/tree/4c17966bf8615ebb609ae07d36a77cd489233a6d/whisper

Home Page

接著我們處理主頁的部分,這部分的頂部會先顯示自己的資訊,接著顯示一個「好友」的提示,為了達成這個效果我們可以使用 CustomScrollView

以下是一個最簡單的示範:

class _HomePageState extends State<HomePage> {
  @override
  Widget build(BuildContext context) {
    return CustomScrollView(
      slivers: <Widget>[
        // 固定加在 list 的前兩個項目
        SliverToBoxAdapter(
          child: Column(
            children: [
              ListTile(
                title: Text("Fixed Item 1"),
                tileColor: Colors.amber[200],
              ),
              ListTile(
                title: Text("Fixed Item 2"),
                tileColor: Colors.amber[100],
              ),
            ],
          ),
        ),
        // 其他項目
        SliverList(
          delegate: SliverChildBuilderDelegate(
            (context, index) => ListTile(
              title: Text('Scrollable Item ${index + 1}'),
            ),
            childCount: 30,
          ),
        ),
      ],
    );
  }
}

https://ithelp.ithome.com.tw/upload/images/20240923/20129540ysdd5yLZ7C.png

接著我們搭配昨天所學一次將其完成,首先先建立一個 Friend Class,我們這裡都把 profile 設成可為 null 的變數,這是因為,用戶可能不設頭貼!

class Friend {
  Friend(
      {required this.profile,
      required this.userName,
      required this.userID,
      required this.channelID});
  String? profile;
  String userName;
  int userID;
  int channelID;
}

接著我們把 FrinedCard 的元件也做出來,當用戶沒有設頭貼時我們使用預設頭貼,暫時以鬼塚夏美代替。

class FriendCard extends StatelessWidget {
  const FriendCard({super.key, required this.friend});
  final Friend friend;

  @override
  Widget build(BuildContext context) {
    return ListTile(
      contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 5),
      leading: friend.profile == null
          ? Image.asset("assets/default_profile.png")
          : Image.network(friend.profile!),
      title: Text(friend.userName),
      onTap: () {
        // TODO
        print("go to ${friend.channelID}");
      },
    );
  }
}

接著我們重新更改 CustomScrollView 使其符合以下畫面

https://ithelp.ithome.com.tw/upload/images/20240923/20129540BbdeZ0mDmU.png

class _HomePageState extends State<HomePage> {
  final _scrollCtrl = ScrollController();

  @override
  void dispose() {
    _scrollCtrl.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    Me me = MeDataLayer.of(context).user!;

    return Scrollbar(
      controller: _scrollCtrl,
      child: CustomScrollView(
        controller: _scrollCtrl,
        slivers: <Widget>[
          SliverToBoxAdapter(
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                const SizedBox(height: 10),
                ListTile(
                  leading: me.profile == null
                      ? Image.asset("assets/default_profile.png")
                      : Image.network(me.profile!),
                  title: Text(
                    me.name,
                    style: const TextStyle(fontWeight: FontWeight.bold),
                  ),
                ),
                const Padding(
                  padding: EdgeInsets.symmetric(horizontal: 16.0, vertical: 10),
                  child: Text("朋友",
                      style: TextStyle(
                          fontWeight: FontWeight.bold, color: Colors.blueGrey)),
                )
              ],
            ),
          ),
          SliverList(
            delegate: SliverChildBuilderDelegate(
              (context, index) => FriendCard(
                friend: Friend(
                    profile: null, userName: "さやか", userID: 0, channelID: 0),
              ),
              childCount: 20,
            ),
          ),
        ],
      ),
    );
  }
}

接著,如同上一篇文所提及,由於載入好友會需要經過伺服器的處理,因此我們也加入一個異步處理及滑至底部載入更多的效果。由於只有底下朋友列表的部分要額外載入,因此我們將這個邏輯分開到另一個 class FriendCardList 來處理

class _HomePageState extends State<HomePage> {
  // ...
  @override
  Widget build(BuildContext context) {
    Me me = MeDataLayer.of(context).user!;
    return Scrollbar(
      controller: _scrollCtrl,
      child: CustomScrollView(
        controller: _scrollCtrl,
        slivers: <Widget>[
          SliverToBoxAdapter(
            // ...
          ),
          const FriendCardList()
        ],
      ),
    );
  }
}
class _FriendCardListState extends State<FriendCardList> {
  final Future<List<Friend>> _loadChatList =
      Future.delayed(const Duration(seconds: 1), () {
    return List<Friend>.generate(
        20,
        (index) => Friend(
            profile: "https://i.imgur.com/91bOTO6.png",
            userName: "さやか-$index",
            userID: 1,
            channelID: 0));
  });

  @override
  Widget build(BuildContext context) {
    return FutureBuilder(
        future: _loadChatList,
        builder: (context, snapshot) {
          if (snapshot.hasError) {
            return SliverToBoxAdapter(
                child:
                    Center(child: Text("Unexpected error ${snapshot.error}")));
          } else if (snapshot.hasData) {
            return SliverList(
              delegate: SliverChildBuilderDelegate(
                (context, index) => FriendCard(
                  friend: snapshot.data![index],
                ),
                childCount: snapshot.data!.length,
              ),
            );
          }
          return const SliverToBoxAdapter(
              child: Center(child: CircularProgressIndicator()));
        });
  }
}

接著我們想要在當滑到底部時,再多載入一些 data,但是這次實作時發現,能決定控制到底的 _scrollCtrl 是在 FutureBuilder 之上層 widget 的,那麼當上層 widget 觸發事件後,下層的 widget 要怎麼更新呢?為了更簡單的解決這個問題,我們可以選擇不使用 FutureBuidler 而是自行管理狀態,直接將下滑載入的模組也用於初始化。

class _HomePageState extends State<HomePage> {
  final _scrollCtrl = ScrollController();
  final List<Friend> _friendList = [];
  bool _isLoadingMore = false;

  @override
  void initState() {
    _loadMoreItems();
    _scrollCtrl.addListener(() {
      if (_scrollCtrl.position.pixels == _scrollCtrl.position.maxScrollExtent) {
        _loadMoreItems();
      }
    });
    super.initState();
  }

  Future<void> _loadMoreItems() async {
    if (!_isLoadingMore) {
      setState(() {
        _isLoadingMore = true;
      });

      await Future.delayed(const Duration(seconds: 1));

      setState(() {
        _friendList.addAll(List<Friend>.generate(
            20,
            (index) => Friend(
                profile: "https://i.imgur.com/91bOTO6.png",
                userName: "さやか-${index + _friendList.length}",
                userID: 1,
                channelID: 0)));
        _isLoadingMore = false;
      });
    }
  }

  @override
  void dispose() {
    _scrollCtrl.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    Me me = MeDataLayer.of(context).user!;

    return Scrollbar(
      controller: _scrollCtrl,
      child: CustomScrollView(
        controller: _scrollCtrl,
        slivers: <Widget>[
          SliverToBoxAdapter(
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                const SizedBox(height: 10),
                ListTile(
                  leading: me.profile == null
                      ? Image.asset("assets/default_profile.png")
                      : Image.network(me.profile!),
                  title: Text(
                    me.name,
                    style: const TextStyle(fontWeight: FontWeight.bold),
                  ),
                ),
                const Padding(
                  padding: EdgeInsets.symmetric(horizontal: 16.0, vertical: 10),
                  child: Text("朋友",
                      style: TextStyle(
                          fontWeight: FontWeight.bold, color: Colors.blueGrey)),
                )
              ],
            ),
          ),
          SliverList(
            delegate: SliverChildBuilderDelegate(
              (context, index) => FriendCard(
                friend: _friendList[index],
              ),
              childCount: _friendList.length,
            ),
          ),
          SliverToBoxAdapter(
              child: Center(
            child: _isLoadingMore
                ? const CircularProgressIndicator()
                : const SizedBox(),
          ))
        ],
      ),
    );
  }
}

Fix

我們可以發現當我們在切換畫面時,上面的 AppBar 的內容根本就沒有跟著一起動,一直顯示聊天主畫面,感覺有點冗餘,我們可以設定使其隨著 BottomNavigationBar 的切換而切換,另外,也不將其上色,更符合審美。

class _MePageState extends State<MePage> {
  int _selectedIndex = 0;
  static const List<Widget> _widgets = [
    HomePage(),
    ChatPage(),
    SettingsPage(),
  ];
  static const List<String> _widgetTitle = [
    '主頁',
    '聊天',
    '設定',
  ];

  void _onItemTapped(int index) {
    setState(() {
      _selectedIndex = index;
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        // 移除 backgroundColor
        // backgroundColor: Theme.of(context).colorScheme.inversePrimary,
        title: Text(_widgetTitle[_selectedIndex]),
      ),
      body: _widgets[_selectedIndex],
      bottomNavigationBar: BottomNavigationBar(
        items: const <BottomNavigationBarItem>[
          // ...
        ],
        currentIndex: _selectedIndex,
        selectedItemColor: Colors.pink,
        onTap: _onItemTapped,
      ),
    );
  }
}

發現上次的錯誤畫面沒有處理好,重新處理了一下,並且包成另一個 Widget:

https://ithelp.ithome.com.tw/upload/images/20240923/20129540ps7C1cxggV.png

class MyErrorWidget extends StatelessWidget {
  const MyErrorWidget(this.err, {super.key});
  final String err;

  @override
  Widget build(BuildContext context) {
    return Center(
        child: Column(
      mainAxisAlignment: MainAxisAlignment.center,
      children: [
        const Icon(
          Icons.error_outline,
          color: Colors.redAccent,
          size: 50,
        ),
        const SizedBox(
          height: 20,
        ),
        Text(err)
      ],
    ));
  }
}

另外有些地方太醜了所以也稍微改了一下,都是一些細節調整:

比如聊天頁面直接改以 ListTile 來做,感覺是 Card 漂亮許多

https://ithelp.ithome.com.tw/upload/images/20240924/20129540gywRofj23d.png

設定頁面的登出則有再做調整,變成可以實際登出的按鈕了

class _SettingsPageState extends State<SettingsPage> {
  bool _isLoggingOut = false;

  @override
  Widget build(BuildContext context) {
    return ListView(
      children: [
        // ...
        ListTile(
          leading: const Icon(Icons.exit_to_app),
          title: Text(!_isLoggingOut ? '登出' : '登出中...'),
          onTap: !_isLoggingOut
              ? () async {
                  setState(() {
                    _isLoggingOut = true;
                  });
                  await Me.logout();
                  if (context.mounted) {
                    MeDataLayer.of(context).setUser(null);
                    Navigator.pushReplacement(context,
                        MaterialPageRoute(builder: (context) {
                      return const LoginPage();
                    }));
                  }
                  setState(() {
                    _isLoggingOut = false;
                  });
                }
              : null,
        )
      ],
    );
  }
}

https://ithelp.ithome.com.tw/upload/images/20240924/20129540dtyMxvd8W8.png

登入頁面也稍微更改了一下

https://ithelp.ithome.com.tw/upload/images/20240924/20129540e1OjOO6WpA.png


感覺 HomePage 還是有點醜醜的,不知道怎麼調😂


上一篇
Day-21 實作(2) Flutter 利用 ListView.buider 實現載入更多
下一篇
Day-23 實作(4) Flutter 如何使用 showDialog 及 reverse ListView
系列文
從零開始以Flutter打造跨平台聊天APP30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言